package delta;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

public class Delta {

	public void delta(Outputter outputter, Collection<File> a, Collection<File> b, File file) throws IOException {
		Collection<ZipFile> zipfiles = new ArrayList<ZipFile>();
		try {
			Map<String, Item> am = scan(a, zipfiles, new LinkedHashMap<String, Item>());
			Map<String, Item> bm = scan(b, zipfiles, new LinkedHashMap<String, Item>());
			Map<String, Item> dm = compare(am, bm, new TreeMap<String, Item>());
			outputter.output(file, dm, new byte[8196]);
		} finally {
			closeAllZipFiles(zipfiles);
		}
	}

	public <M extends Map<String, Item>> M compare(Map<String, Item> a, Map<String, Item> b, M delta) {
		for (Map.Entry<String, Item> e : b.entrySet()) {
			String name = e.getKey();
			Item bi = e.getValue();
			Item ai = a.get(name);
			if (ai == null || !identical(ai, bi)) {
				Item p = delta.put(name, bi);
				assert p == null : name;
			}
		}
		for (Map.Entry<String, Item> e : a.entrySet()) {
			String name = e.getKey();
			Item bi = b.get(name);
			if (bi == null) {
				Item p = delta.put(name, null);
				assert p == null : name;
			}
		}
		return delta;
	}

	boolean identical(Item ai, Item bi) {
		if (ai.getSize() != bi.getSize()) return false;
		try {
			InputStreamComparator comparator = new InputStreamComparator();
			InputStream ais = ai.newInputStream();
			try {
				InputStream bis = bi.newInputStream();
				try {
					return comparator.compare(ais, bis) == 0;
				} finally {
					bis.close();
				}
			} finally {
				ais.close();
			}
		} catch (IOException exc) {
			log("%s : %s <=> %s : %s :: %s\n", ai.getBase(), ai.getName(), bi.getBase(), bi.getName(), exc.getMessage());
			return false;
		}
	}

	void addItem(Map<String, Item> items, Item item) {
		String name = item.getName();
		Item prev = items.put(name, item);
		if (prev != null) {
			items.put(name, prev);
			log("%s : %s is hidden by %s\n", item.getBase(), name, prev.getBase());
		}
	}

	public <M extends Map<String, Item>> M scan(Collection<File> bases, Collection<ZipFile> zipfiles, M items) {
		for (File base : bases) {
			scan(base, zipfiles, items);
		}
		return items;
	}

	public <M extends Map<String, Item>> M scan(File base, Collection<ZipFile> zipfiles, M items) {
		if (!base.exists()) {
			log("%s does not exist\n", base);
		}
		if (base.isFile()) {
			try {
				ZipFile zipfile = new ZipFile(base);
				zipfiles.add(zipfile);
				Enumeration<? extends ZipEntry> entries = zipfile.entries();
				while (entries.hasMoreElements()) {
					ZipEntry entry = entries.nextElement();
					if (!entry.isDirectory()) {
						addItem(items, new ZipItem(zipfile, entry.getName()));
					}
				}
			} catch (IOException exc) {
				log("%s : %s\n", base, exc.getMessage());
			}
		} else if (base.isDirectory()) {
			ArrayDeque<File> stack = new ArrayDeque<File>();
			stack.offerLast(base);
			File file;
			while ((file = stack.pollLast()) != null) {
				if (file.isDirectory()) {
					File[] children = file.listFiles();
					for (int i = children.length - 1; i >= 0; --i) {
						stack.offerLast(children[i]);
					}
				} else if (file.isFile()) {
					addItem(items, new DirItem(base, file));
				}
			}
		} else {
			log("%s is neither file nor directory\n", base);
		}
		return items;
	}

	void closeAllZipFiles(Collection<ZipFile> zipfiles) {
		for (ZipFile zipfile : zipfiles) {
			try {
				zipfile.close();
			} catch (IOException exc) {
				log("%s :: %s\n", zipfile.getName(), exc.getMessage());
			}
		}
	}

	void log(String message, Object... params) {
		System.out.printf(message, params);
	}

	public static abstract class Item {
		final File base_;
		final String name_;

		public Item(File base, String name) {
			assert base.exists() : base;
			base_ = base;
			name_ = name;
		}

		public File getBase() {
			return base_;
		}

		public String getName() {
			return name_;
		}

		public abstract long getSize();

		public abstract InputStream newInputStream() throws IOException;

		public abstract void close() throws IOException;
	}

	public static class DirItem extends Item {
		final File file_;

		public DirItem(File base, File file) {
			super(base, getRelativePath(base, file));
			assert base.isDirectory() : base;
			assert file.isFile() : file;
			file_ = file;
		}

		static String getRelativePath(File base, File file) {
			base = base.getAbsoluteFile();
			file = file.getAbsoluteFile();
			List<String> parts = new ArrayList<String>();
			for (; !base.equals(file); file = file.getParentFile()) {
				parts.add(file.getName());
			}
			assert !parts.isEmpty() : base + " " + file;
			int s = parts.size();
			StringBuilder builder = new StringBuilder(parts.get(s - 1));
			for (int i = s - 2; i >= 0; --i) {
				builder.append('/').append(parts.get(i));
			}
			return builder.toString();
		}

		public long getSize() {
			return file_.length();
		}

		public InputStream newInputStream() throws IOException {
			return new FileInputStream(file_);
		}

		public void close() {
			// do nothing
		}
	}

	public static class ZipItem extends Item {
		final ZipFile file_;

		public ZipItem(ZipFile file, String name) {
			super(new File(file.getName()), name);
			file_ = file;
		}

		public long getSize() {
			return file_.getEntry(name_).getSize();
		}

		public InputStream newInputStream() throws IOException {
			return file_.getInputStream(file_.getEntry(name_));
		}

		public void close() throws IOException {
			file_.close();
		}
	}

	public abstract static class Outputter {
		public abstract void output(File file, Map<String, Item> d, byte[] buffer) throws IOException;

		void outputToStream(OutputStream os, Item item, byte[] buffer) throws IOException {
			if (item != null) {
				InputStream is = item.newInputStream();
				try {
					copy(buffer, is, os);
				} finally {
					is.close();
				}
			}
		}

		void copy(byte[] buffer, InputStream is, OutputStream os) throws IOException {
			int r;
			while ((r = is.read(buffer)) >= 0) {
				os.write(buffer, 0, r);
			}
		}
	}

	public static class DirOutputter extends Outputter {
		public void output(File dir, Map<String, Item> d, byte[] buffer) throws IOException {
			clean(dir);
			for (Map.Entry<String, Item> e : d.entrySet()) {
				String name = e.getKey();
				File file = new File(dir, name);
				file.getParentFile().mkdirs();
				FileOutputStream os = new FileOutputStream(file);
				try {
					outputToStream(os, e.getValue(), buffer);
				} finally {
					os.close();
				}
			}
		}

		void clean(File dir) {
			if (dir.exists()) {
				if (dir.isDirectory()) {
					for (File f : dir.listFiles()) {
						clean(f);
					}
				}
				dir.delete();
			}
		}
	}

	public static class ZipOutputter extends Outputter {
		public void output(File zip, Map<String, Item> d, byte[] buffer) throws IOException {
			zip.getParentFile().mkdirs();
			ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zip));
			try {
				for (Map.Entry<String, Item> e : d.entrySet()) {
					zos.putNextEntry(new ZipEntry(e.getKey()));
					try {
						outputToStream(zos, e.getValue(), buffer);
					} finally {
						zos.closeEntry();
					}
				}
			} finally {
				zos.close();
			}
		}
	}
}
